Skip to main content

Sending and Landing Transactions

In this guide, we'll explore how to send the instructions using the Solana SDK - both in Typescript and Rust. We'll cover the following key topics:

  • Client-side retry
  • Prioritization fees
  • Compute budget estimation

We also cover key considerations for sending transactions in web applications with wallet extensions, along with additional steps to improve transaction landing.

Make sure you check out this doc to set up your environment.

Code Overview

1. Dependencies

Let's start by importing the necessary dependencies from Solana's SDKs.

package.json
"dependencies": {
"@solana-program/compute-budget": "^0.6.1",
"@solana/web3.js": "^2.0.0",
},
sendTransaction.ts
import { 
createSolanaRpc,
address,
pipe,
createTransactionMessage,
setTransactionMessageFeePayer,
setTransactionMessageLifetimeUsingBlockhash,
appendTransactionMessageInstructions,
prependTransactionMessageInstructions,
signTransactionMessageWithSigners,
getComputeUnitEstimateForTransactionMessageFactory,
getBase64EncodedWireTransaction,
setTransactionMessageFeePayerSigner
} from '@solana/web3.js';
import {
getSetComputeUnitLimitInstruction,
getSetComputeUnitPriceInstruction
} from '@solana-program/compute-budget';

2. Create Transaction Message From Instructions

To send a transaction on Solana, you need to include a blockhash to the transaction. A blockhash acts as a timestamp and ensures the transaction has a limited lifetime. Validators use the blockhash to verify the recency of a transaction before including it in a block. A transaction referencing a blockhash is only valid for 150 blocks (~1-2 minutes, depending on slot time). After that, the blockhash expires, and the transaction will be rejected.

Durable Nonces: In some cases, you might need a transaction to remain valid for longer than the typical blockhash lifespan, such as when scheduling future payments or collecting multi-signature approvals over time. In that case, you can use durable nonces to sign the transaction, which includes a nonce in place of a recent blockhash.

You also need to add the signers to the transactions. With Solana web3.js v2, you can create instructions and add additional signers as TransactionSigner to the instructions. The Typescript Whirlpools SDK leverages this functioanlity and appends all additional signers to the instructions for you. In Rust, this feautures is not available. Therefore, the Rust Whirlpools SDK may return instruction_result.additional_signers if there are any, and you need to manually append them to the transaction.

Here's how the transaction message is created:

sendTransaction.ts
const { instructions } = // get instructions from Whirlpools SDK
const latestBlockHash = await rpc.getLatestBlockhash().send();
const transactionMessage = await pipe(
createTransactionMessage({ version: 0}),
tx => setTransactionMessageFeePayer(wallet.address, tx),
tx => setTransactionMessageLifetimeUsingBlockhash(latestBlockHash.value, tx),
tx => appendTransactionMessageInstructions(instructions, tx)
)

3. Estimating Compute Unit Limit and Prioritization Fee

Before sending a transaction, it's important to set a compute unit limit and an appropriate prioritization fee.

Transactions that request fewer compute units get high priority for the same amount of prioritization fee (which is defined per compute unit). Setting the compute units too low will result in a failed transaction.

You can get an estimate of the compute units by simulating the transaction on the RPC. To avoid transaction failures caused by underestimating this limit, you can add an additional 100,000 compute units, but you can adjust this based on your own tests.

The prioritization fee per compute unit also incentivizes validators to prioritize your transaction, especially during times of network congestion. You can call the getRecentPrioritizationFees RPC method to retrieve an array of 150 values, where each value represents the lowest priority fee paid for transactions that landed in each of the past 150 blocks. In this example, we sort that list and select the 50th percentile, but you can adjust this if needed. The prioritization fee is provided in micro-lamports per compute unit. The total priority fee in lamports you will pay is calculated as (estimated compute unitsprioritization fee)/106(\text{estimated compute units} \cdot \text{prioritization fee}) / 10^6.

sendTransaction.ts
const getComputeUnitEstimateForTransactionMessage = 
getComputeUnitEstimateForTransactionMessageFactory({
rpc
});
const computeUnitEstimate = await getComputeUnitEstimateForTransactionMessage(transactionMessage) + 100_000;
const medianPrioritizationFee = await rpc.getRecentPrioritizationFees()
.send()
.then(fees =>
fees
.map(fee => Number(fee.prioritizationFee))
.sort((a, b) => a - b)
[Math.floor(fees.length / 2)]
);
const transactionMessageWithComputeUnitInstructions = await prependTransactionMessageInstructions([
getSetComputeUnitLimitInstruction({ units: computeUnitEstimate }),
getSetComputeUnitPriceInstruction({ microLamports: medianPrioritizationFee })
], transactionMessage);

4. Sign and Submit Transaction

Finally, the transaction needs to be signed, encoded, and submitted to the network. A client-side time-base retry mechanism ensures that the transaction is repeatedly sent until it is confirmed or the time runs out. We use a time-based loop, because we know that the lifetime of a transaction is 150 blocks, which on average takes about 79-80 seconds. The signing of the transactions is an idempotent operation and produces a transaction hash, which acts as the transaction ID. Since transactions can be added only once to the block chain, we can keep sending the transaction during the lifetime of the trnsaction.

You're probably wondering why we don't just use the widely used sendAndConfirm method. This is because the retry mechanism of the sendAndConfirm method is executed on the RPC. By default, RPC nodes will try to forward (rebroadcast) transactions to leaders every two seconds until either the transaction is finalized, or the transaction's blockhash expires. If the outstanding rebroadcast queue size is greater than 10,000 transaction, newly submitted transactions are dropped. This means that at times of congestion, your transaction might not even arrive at the RPC in the first place. Moreover, the confirmTransaction RPC method that sendAndConfirm calls is deprecated.

sendTransaction.ts
const signedTransaction = await signTransactionMessageWithSigners(transactionMessageWithComputeUnitInstructions)
const base64EncodedWireTransaction = getBase64EncodedWireTransaction(signedTransaction);

const timeoutMs = 90000;
const startTime = Date.now();

while (Date.now() - startTime < timeoutMs) {
const transactionStartTime = Date.now();

const signature = await rpc.sendTransaction(base64EncodedWireTransaction, {
maxRetries: 0n,
skipPreflight: true,
encoding: 'base64'
}).send();

const statuses = await rpc.getSignatureStatuses([signature]).send();
if (statuses.value[0]) {
if (!statuses.value[0].err) {
console.log(`Transaction confirmed: ${signature}`);
break;
} else {
console.error(`Transaction failed: ${statuses.value[0].err.toString()}`);
break;
}
}

const elapsedTime = Date.now() - transactionStartTime;
const remainingTime = Math.max(0, 1000 - elapsedTime);
if (remainingTime > 0) {
await new Promise(resolve => setTimeout(resolve, remainingTime));
}
}

Handling transactions with Wallets in web apps.

Creating Noop Signers

When sending transactions from your web application, users need to sign the transaction using their wallet. Since the transaction needs to assembled beforehand, you can create a noopSigner (no-operation signer) and add it to the instructions. This will act as a placeholder for you instructions, indicating that a given account is a signer and the signature wil be added later. After assembling the transaction you can pass it to the wallet extension. If the user signs, it will return a serialized transaction with the added signature.

Prioritization Fees

Some wallets will calculate and apply priority fees for your transactions, provided:

  • The transaction does not already have signatures present.
  • The transaction does not have existing compute-budget instructions.
  • The transactions will still be less than the maximum transaction size fo 1232 bytes, after applying compute-budget instructions.

Additional Improvements for Landing Transactions

  • You could send your transaction to multiple RPC nodes at the same time, all within each iteration of the time-based loop.
  • At the time of writing, 85% of Solana validators are Jito validators. Jito validators happily accept an additional tip, in the form a SOL transfer, to prioritize a transaction. A good place to get familiarized with Jito is here: https://www.jito.network/blog/jito-solana-is-now-open-source/
  • Solana gives staked validators more reliable performance when sending transactions by routing them through prioritized connections. This mechanism is referred to as stake-weighted Quality of Service (swQoS). Validators can extend this service to RPC nodes, essentially giving staked connections to RPC nodes as if they were validators with that much stake in the network. RPC providers, like Helius and Titan, expose such peered RPC nodes to paid users, allowing users to send transactions to RPC nodes which use the validator's staked connections. From the RPC, the transaction is then sent over the staked connection with a lower likelihood of being delayed or dropped.